--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 2e043ec1c244bb6849ae89c39d1a77a1896610e5
Parents : 301c35c
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-14T19:30:51-05:00
feat(theme): introduce semantic design tokens and integrate with Tailwind and Vuetify themes
Changes
5 files changed, 362 insertions(+), 111 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index 49890501..54f86f12 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -4,6 +4,9 @@ import { createI18n } from "vue-i18n";
import vClickOutside from "click-outside-vue3";
import DOMPurify from "dompurify";
import "./style.css";
+import { injectMeshchatThemeVariables, vuetifyThemesFromTokens } from "./theme/designTokens.js";
+
+injectMeshchatThemeVariables();
window.DOMPurify = DOMPurify;
import "@mdi/font/css/materialdesignicons.css";
@@ -35,34 +38,7 @@ import { createVuetify } from "vuetify";
const vuetify = createVuetify({
theme: {
defaultTheme: "light",
- themes: {
- light: {
- dark: false,
- colors: {
- background: "#f8fafc",
- surface: "#ffffff",
- primary: "#2563eb",
- secondary: "#475569",
- error: "#dc2626",
- info: "#0284c7",
- success: "#16a34a",
- warning: "#f97316",
- },
- },
- dark: {
- dark: true,
- colors: {
- background: "#09090b",
- surface: "#18181b",
- primary: "#60a5fa",
- secondary: "#94a3b8",
- error: "#f87171",
- info: "#38bdf8",
- success: "#34d399",
- warning: "#fb923c",
- },
- },
- },
+ themes: vuetifyThemesFromTokens(),
},
});
diff --git a/meshchatx/src/frontend/style.css b/meshchatx/src/frontend/style.css
index df7ad7ad..a31b6225 100644
--- a/meshchatx/src/frontend/style.css
+++ b/meshchatx/src/frontend/style.css
@@ -4,7 +4,7 @@
* {
scrollbar-width: thin;
- scrollbar-color: #94a3b8 #e2e8f0;
+ scrollbar-color: var(--mc-scrollbar-thumb) var(--mc-scrollbar-track);
}
*::-webkit-scrollbar {
@@ -13,150 +13,124 @@
}
*::-webkit-scrollbar-track {
- background: #e2e8f0;
+ background: var(--mc-scrollbar-track);
}
*::-webkit-scrollbar-thumb {
- background-color: #94a3b8;
+ background-color: var(--mc-scrollbar-thumb);
border-radius: 999px;
- border: 2px solid #e2e8f0;
-}
-
-.dark * {
- scrollbar-color: #52525b #18181b;
-}
-
-.dark *::-webkit-scrollbar-track {
- background: #18181b;
-}
-
-.dark *::-webkit-scrollbar-thumb {
- background-color: #52525b;
- border-color: #18181b;
+ border: 2px solid var(--mc-scrollbar-thumb-border);
}
.glass-card {
- @apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl px-4 py-4;
+ @apply bg-sem-glass backdrop-blur border border-sem-border-card rounded-3xl shadow-xl px-4 py-4;
}
.input-field {
- @apply bg-gray-50/90 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
+ @apply bg-sem-surface-muted border border-sem-border text-sm rounded-2xl focus:ring-2 focus:ring-sem-focus focus:border-sem-focus-border block w-full p-2.5 text-sem-fg transition;
}
select.input-field option {
- @apply bg-white text-gray-900 dark:bg-zinc-900 dark:text-gray-100;
+ @apply bg-sem-surface text-sem-fg;
}
.primary-chip {
- @apply inline-flex items-center justify-center gap-x-2 rounded-full bg-blue-600 px-4 py-2 text-xs font-bold text-white shadow-sm transition-all !important;
+ @apply inline-flex items-center justify-center gap-x-2 rounded-full bg-sem-accent px-4 py-2 text-xs font-bold text-white shadow-sm transition-all !important;
}
.primary-chip:hover {
- @apply bg-blue-500 -translate-y-0.5 !important;
+ @apply bg-sem-accent-hover -translate-y-0.5 !important;
}
.primary-chip:active {
@apply scale-95 translate-y-0 !important;
}
.secondary-chip {
- @apply inline-flex items-center justify-center gap-x-2 rounded-full border border-gray-300 px-4 py-2 text-xs font-bold shadow-sm transition-all duration-200 !important;
- background-color: white !important;
- color: #374151 !important; /* text-gray-700 */
+ @apply inline-flex items-center justify-center gap-x-2 rounded-full px-4 py-2 text-xs font-bold shadow-sm transition-all duration-200 !important;
+ background-color: var(--mc-secondary-chip-bg) !important;
+ color: var(--mc-secondary-chip-text) !important;
+ border: 1px solid var(--mc-secondary-chip-border) !important;
}
.secondary-chip:hover {
- background-color: #f9fafb !important; /* bg-gray-50 */
- color: #111827 !important; /* text-gray-900 */
- border-color: #60a5fa !important; /* border-blue-400 */
+ background-color: var(--mc-secondary-chip-hover-bg) !important;
+ color: var(--mc-secondary-chip-hover-text) !important;
+ border-color: var(--mc-secondary-chip-hover-border) !important;
@apply -translate-y-0.5 shadow-md !important;
}
.secondary-chip:active {
@apply scale-95 translate-y-0 shadow-sm !important;
}
-.dark .secondary-chip {
- background-color: #18181b !important; /* bg-zinc-900 */
- color: #f4f4f5 !important; /* text-zinc-100 */
- border-color: #3f3f46 !important; /* border-zinc-700 */
-}
.dark .secondary-chip:hover {
- background-color: #27272a !important; /* bg-zinc-800 */
- color: #ffffff !important; /* text-white */
- border-color: #3b82f6 !important; /* border-blue-500 */
- @apply shadow-lg shadow-blue-500/10 !important;
+ @apply shadow-lg dark:shadow-blue-500/10 !important;
}
.danger-chip {
- @apply inline-flex items-center justify-center gap-x-2 rounded-full border border-red-200 px-4 py-2 text-xs font-bold transition-all duration-200 !important;
- background-color: #fef2f2 !important; /* bg-red-50 */
- color: #b91c1c !important; /* text-red-700 */
+ @apply inline-flex items-center justify-center gap-x-2 rounded-full px-4 py-2 text-xs font-bold transition-all duration-200 !important;
+ background-color: var(--mc-danger-chip-bg) !important;
+ color: var(--mc-danger-chip-text) !important;
+ border: 1px solid var(--mc-danger-chip-border) !important;
}
.danger-chip:hover {
- background-color: #fee2e2 !important; /* bg-red-100 */
- color: #991b1b !important; /* text-red-800 */
+ background-color: var(--mc-danger-chip-hover-bg) !important;
+ color: var(--mc-danger-chip-hover-text) !important;
@apply -translate-y-0.5 shadow-md !important;
}
.danger-chip:active {
@apply scale-95 translate-y-0 !important;
}
-.dark .danger-chip {
- background-color: rgba(69, 10, 10, 0.2) !important; /* bg-red-950/20 */
- color: #f87171 !important; /* text-red-400 */
- border-color: rgba(127, 29, 29, 0.5) !important; /* border-red-900/50 */
-}
.dark .danger-chip:hover {
- background-color: rgba(127, 29, 29, 0.4) !important; /* bg-red-900/40 */
- color: #fca5a5 !important; /* text-red-300 */
- @apply shadow-lg shadow-red-500/10 !important;
+ @apply shadow-lg dark:shadow-red-500/10 !important;
}
.glass-label {
- @apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
+ @apply mb-1 text-sm font-semibold text-sem-fg-secondary dark:text-sem-fg-label;
}
.monospace-field {
- @apply font-mono tracking-tight text-gray-900 dark:text-white;
+ @apply font-mono tracking-tight text-sem-fg dark:text-white;
}
.metric-row {
- @apply grid gap-3 sm:grid-cols-2 text-gray-800 dark:text-gray-100;
+ @apply grid gap-3 sm:grid-cols-2 text-sem-fg-secondary dark:text-sem-fg;
}
.metric-value {
- @apply text-lg font-semibold text-gray-900 dark:text-white;
+ @apply text-lg font-semibold text-sem-fg dark:text-white;
}
.address-card {
- @apply relative border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white/80 dark:bg-zinc-900/70 p-4 space-y-2;
+ @apply relative border border-sem-border-card rounded-2xl bg-sem-surface-raised p-4 space-y-2;
}
.address-card__label {
- @apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
+ @apply text-xs uppercase tracking-wide text-sem-fg-muted;
}
.address-card__value {
- @apply text-sm text-gray-900 dark:text-white break-words pr-16;
+ @apply text-sm text-sem-fg dark:text-white break-words pr-16;
}
.address-card__action {
- @apply absolute top-3 right-3 inline-flex items-center gap-1 rounded-full border border-gray-200 px-3 py-1 text-[10px] font-bold text-gray-700 bg-white shadow-sm transition-all !important;
+ @apply absolute top-3 right-3 inline-flex items-center gap-1 rounded-full px-3 py-1 text-[10px] font-bold shadow-sm transition-all !important;
+ background-color: var(--mc-address-action-bg) !important;
+ color: var(--mc-address-action-text) !important;
+ border: 1px solid var(--mc-address-action-border) !important;
}
.address-card__action:hover {
- @apply bg-gray-50 text-gray-900 border-blue-400 !important;
-}
-.dark .address-card__action {
- @apply border-zinc-700 text-zinc-100 bg-zinc-900 !important;
-}
-.dark .address-card__action:hover {
- @apply bg-zinc-800 text-white border-blue-500 !important;
+ background-color: var(--mc-address-action-hover-bg) !important;
+ color: var(--mc-address-action-hover-text) !important;
+ border-color: var(--mc-address-action-hover-border) !important;
}
.file-input {
- @apply block w-full text-xs cursor-pointer rounded-lg border border-gray-300 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500;
+ @apply block w-full text-xs cursor-pointer rounded-lg border bg-sem-file-input-bg text-sem-file-input-text focus:outline-none focus:ring-2 focus:ring-sem-focus;
+ border-color: var(--mc-file-input-border);
}
.file-input::file-selector-button {
- @apply mr-2 border-0 rounded-md bg-blue-600 text-white px-3 py-1 text-xs font-semibold cursor-pointer hover:bg-blue-500 transition;
+ @apply mr-2 border-0 rounded-md bg-sem-accent text-white px-3 py-1 text-xs font-semibold cursor-pointer hover:bg-sem-accent-hover transition;
}
.dark .file-input::file-selector-button {
- @apply bg-blue-500 hover:bg-blue-400 text-white;
+ @apply bg-sem-accent hover:bg-sem-accent-hover text-white;
}
.no-scrollbar::-webkit-scrollbar {
@@ -178,21 +152,13 @@ select.input-field option {
}
.custom-scrollbar::-webkit-scrollbar-thumb {
- background: #cbd5e1;
+ background: var(--mc-custom-scroll-thumb);
border-radius: 10px;
border: none;
}
-.dark .custom-scrollbar::-webkit-scrollbar-thumb {
- background: #3f3f46;
-}
-
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
- background: #94a3b8;
-}
-
-.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
- background: #52525b;
+ background: var(--mc-custom-scroll-thumb-hover);
}
@keyframes spin-reverse {
@@ -209,17 +175,24 @@ select.input-field option {
}
.context-menu-panel {
- @apply fixed min-w-48 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl;
+ @apply fixed min-w-48 rounded-xl border shadow-xl bg-sem-surface;
+ border-color: var(--mc-border);
}
.context-item {
- @apply w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800 flex items-center gap-2;
+ @apply w-full text-left px-3 py-2 text-sm flex items-center gap-2;
+ color: var(--mc-context-item-text);
+}
+.context-item:hover {
+ background-color: var(--mc-context-hover);
}
.context-menu-divider {
- @apply border-t border-gray-100 dark:border-zinc-800 my-1;
+ border-color: var(--mc-context-divider);
+ @apply border-t my-1;
}
.context-menu-section-label {
- @apply px-3 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest;
+ @apply px-3 py-1.5 text-[10px] font-black uppercase tracking-widest;
+ color: var(--mc-context-section);
}
diff --git a/meshchatx/src/frontend/theme/designTokens.js b/meshchatx/src/frontend/theme/designTokens.js
new file mode 100644
index 00000000..fa2e9345
--- /dev/null
+++ b/meshchatx/src/frontend/theme/designTokens.js
@@ -0,0 +1,215 @@
+/**
+ * Semantic design tokens for MeshChatX. CSS custom properties (--mc-*) are the
+ * single source of truth for colors shared by Tailwind (`sem-*`), global CSS in
+ * style.css, and the Vuetify theme (see vuetifyThemesFromTokens).
+ */
+
+const MESHCHAT_THEME_STYLE_ID = "meshchat-design-tokens";
+
+/** @type {Record<string, string>} */
+export const MESHCHAT_THEME_VARIABLES_LIGHT = {
+ "--mc-canvas": "#f8fafc",
+ "--mc-surface": "#ffffff",
+ "--mc-border": "#e5e7eb",
+ "--mc-border-card": "#e5e7eb",
+ "--mc-border-strong": "#d1d5db",
+ "--mc-text": "#111827",
+ "--mc-text-secondary": "#1f2937",
+ "--mc-text-muted": "#6b7280",
+ "--mc-text-label": "#1f2937",
+ "--mc-focus": "#60a5fa",
+ "--mc-focus-border": "#60a5fa",
+ "--mc-accent": "#2563eb",
+ "--mc-accent-hover": "#3b82f6",
+ "--mc-scrollbar-thumb": "#94a3b8",
+ "--mc-scrollbar-track": "#e2e8f0",
+ "--mc-scrollbar-thumb-border": "#e2e8f0",
+ "--mc-glass-surface": "rgb(255 255 255 / 0.95)",
+ "--mc-surface-muted": "rgb(249 250 251 / 0.9)",
+ "--mc-surface-raised": "rgb(255 255 255 / 0.8)",
+ "--mc-secondary-chip-bg": "#ffffff",
+ "--mc-secondary-chip-text": "#374151",
+ "--mc-secondary-chip-border": "#d1d5db",
+ "--mc-secondary-chip-hover-bg": "#f9fafb",
+ "--mc-secondary-chip-hover-text": "#111827",
+ "--mc-secondary-chip-hover-border": "#60a5fa",
+ "--mc-danger-chip-bg": "#fef2f2",
+ "--mc-danger-chip-text": "#b91c1c",
+ "--mc-danger-chip-border": "#fecaca",
+ "--mc-danger-chip-hover-bg": "#fee2e2",
+ "--mc-danger-chip-hover-text": "#991b1b",
+ "--mc-input-option-bg": "#ffffff",
+ "--mc-input-option-text": "#111827",
+ "--mc-file-input-bg": "#f9fafb",
+ "--mc-file-input-text": "#1f2937",
+ "--mc-file-input-border": "#d1d5db",
+ "--mc-address-action-bg": "#ffffff",
+ "--mc-address-action-text": "#374151",
+ "--mc-address-action-border": "#e5e7eb",
+ "--mc-address-action-hover-bg": "#f9fafb",
+ "--mc-address-action-hover-text": "#111827",
+ "--mc-address-action-hover-border": "#60a5fa",
+ "--mc-custom-scroll-thumb": "#cbd5e1",
+ "--mc-custom-scroll-thumb-hover": "#94a3b8",
+ "--mc-context-hover": "#f3f4f6",
+ "--mc-context-divider": "#f3f4f6",
+ "--mc-context-section": "#9ca3af",
+ "--mc-context-item-text": "#374151",
+ "--mc-info": "#0284c7",
+ "--mc-success": "#16a34a",
+ "--mc-warning": "#f97316",
+ "--mc-error": "#dc2626",
+};
+
+/** @type {Record<string, string>} */
+export const MESHCHAT_THEME_VARIABLES_DARK = {
+ "--mc-canvas": "#09090b",
+ "--mc-surface": "#18181b",
+ "--mc-border": "#3f3f46",
+ "--mc-border-card": "#27272a",
+ "--mc-border-strong": "#52525b",
+ "--mc-text": "#f3f4f6",
+ "--mc-text-secondary": "#ffffff",
+ "--mc-text-muted": "#9ca3af",
+ "--mc-text-label": "#e5e7eb",
+ "--mc-focus": "#3b82f6",
+ "--mc-focus-border": "#3b82f6",
+ "--mc-accent": "#60a5fa",
+ "--mc-accent-hover": "#3b82f6",
+ "--mc-scrollbar-thumb": "#52525b",
+ "--mc-scrollbar-track": "#18181b",
+ "--mc-scrollbar-thumb-border": "#18181b",
+ "--mc-glass-surface": "rgb(24 24 27 / 0.85)",
+ "--mc-surface-muted": "rgb(24 24 27 / 0.8)",
+ "--mc-surface-raised": "rgb(24 24 27 / 0.7)",
+ "--mc-secondary-chip-bg": "#18181b",
+ "--mc-secondary-chip-text": "#f4f4f5",
+ "--mc-secondary-chip-border": "#3f3f46",
+ "--mc-secondary-chip-hover-bg": "#27272a",
+ "--mc-secondary-chip-hover-text": "#ffffff",
+ "--mc-secondary-chip-hover-border": "#3b82f6",
+ "--mc-danger-chip-bg": "rgb(69 10 10 / 0.2)",
+ "--mc-danger-chip-text": "#f87171",
+ "--mc-danger-chip-border": "rgb(127 29 29 / 0.5)",
+ "--mc-danger-chip-hover-bg": "rgb(127 29 29 / 0.4)",
+ "--mc-danger-chip-hover-text": "#fca5a5",
+ "--mc-input-option-bg": "#18181b",
+ "--mc-input-option-text": "#f3f4f6",
+ "--mc-file-input-bg": "#18181b",
+ "--mc-file-input-text": "#f3f4f6",
+ "--mc-file-input-border": "#3f3f46",
+ "--mc-address-action-bg": "#18181b",
+ "--mc-address-action-text": "#f4f4f5",
+ "--mc-address-action-border": "#3f3f46",
+ "--mc-address-action-hover-bg": "#27272a",
+ "--mc-address-action-hover-text": "#ffffff",
+ "--mc-address-action-hover-border": "#3b82f6",
+ "--mc-custom-scroll-thumb": "#3f3f46",
+ "--mc-custom-scroll-thumb-hover": "#52525b",
+ "--mc-context-hover": "#27272a",
+ "--mc-context-divider": "#27272a",
+ "--mc-context-section": "#71717a",
+ "--mc-context-item-text": "#e4e4e7",
+ "--mc-info": "#38bdf8",
+ "--mc-success": "#34d399",
+ "--mc-warning": "#fb923c",
+ "--mc-error": "#f87171",
+};
+
+/**
+ * Tailwind theme.extend.colors.sem — uses the same --mc-* variables as style.css.
+ * Keep keys in sync with usages in style.css and Vue (e.g. bg-sem-canvas).
+ */
+export function tailwindSemanticColorExtend() {
+ return {
+ canvas: "var(--mc-canvas)",
+ surface: "var(--mc-surface)",
+ border: "var(--mc-border)",
+ "border-card": "var(--mc-border-card)",
+ "border-strong": "var(--mc-border-strong)",
+ fg: "var(--mc-text)",
+ "fg-secondary": "var(--mc-text-secondary)",
+ "fg-muted": "var(--mc-text-muted)",
+ "fg-label": "var(--mc-text-label)",
+ accent: "var(--mc-accent)",
+ "accent-hover": "var(--mc-accent-hover)",
+ focus: "var(--mc-focus)",
+ glass: "var(--mc-glass-surface)",
+ "surface-muted": "var(--mc-surface-muted)",
+ "surface-raised": "var(--mc-surface-raised)",
+ info: "var(--mc-info)",
+ success: "var(--mc-success)",
+ warning: "var(--mc-warning)",
+ danger: "var(--mc-error)",
+ "focus-border": "var(--mc-focus-border)",
+ "file-input-bg": "var(--mc-file-input-bg)",
+ "file-input-text": "var(--mc-file-input-text)",
+ };
+}
+
+function serializeVarsBlock(vars) {
+ return Object.entries(vars)
+ .map(([k, v]) => ` ${k}: ${v};`)
+ .join("\n");
+}
+
+/**
+ * Injects :root and .dark variable blocks. Idempotent. Call once from main.js before mount.
+ * @param {Document} [doc]
+ */
+export function injectMeshchatThemeVariables(doc = typeof document !== "undefined" ? document : null) {
+ if (!doc?.head) {
+ return;
+ }
+ const existing = doc.getElementById(MESHCHAT_THEME_STYLE_ID);
+ if (existing) {
+ existing.remove();
+ }
+ const el = doc.createElement("style");
+ el.id = MESHCHAT_THEME_STYLE_ID;
+ el.textContent = [
+ ":root {",
+ serializeVarsBlock(MESHCHAT_THEME_VARIABLES_LIGHT),
+ "}",
+ ".dark {",
+ serializeVarsBlock(MESHCHAT_THEME_VARIABLES_DARK),
+ "}",
+ ].join("\n");
+ doc.head.appendChild(el);
+}
+
+/**
+ * Vuetify 3 theme definition derived from the same hexes as CSS light/dark tokens.
+ */
+export function vuetifyThemesFromTokens() {
+ const L = MESHCHAT_THEME_VARIABLES_LIGHT;
+ const D = MESHCHAT_THEME_VARIABLES_DARK;
+ return {
+ light: {
+ dark: false,
+ colors: {
+ background: L["--mc-canvas"],
+ surface: L["--mc-surface"],
+ primary: L["--mc-accent"],
+ secondary: "#475569",
+ error: L["--mc-error"],
+ info: L["--mc-info"],
+ success: L["--mc-success"],
+ warning: L["--mc-warning"],
+ },
+ },
+ dark: {
+ dark: true,
+ colors: {
+ background: D["--mc-canvas"],
+ surface: D["--mc-surface"],
+ primary: D["--mc-accent"],
+ secondary: "#94a3b8",
+ error: D["--mc-error"],
+ info: D["--mc-info"],
+ success: D["--mc-success"],
+ warning: D["--mc-warning"],
+ },
+ },
+ };
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 430b325f..ff3710a4 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,4 +1,5 @@
import formsPlugin from "@tailwindcss/forms";
+import { tailwindSemanticColorExtend } from "./meshchatx/src/frontend/theme/designTokens.js";
const frontendRoot = "./meshchatx/src/frontend";
@@ -11,7 +12,11 @@ module.exports = {
"./meshchatx/src/backend/markdown_renderer.py",
],
theme: {
- extend: {},
+ extend: {
+ colors: {
+ sem: tailwindSemanticColorExtend(),
+ },
+ },
},
plugins: [formsPlugin],
};
diff --git a/tests/frontend/designTokens.test.js b/tests/frontend/designTokens.test.js
new file mode 100644
index 00000000..0a326c16
--- /dev/null
+++ b/tests/frontend/designTokens.test.js
@@ -0,0 +1,82 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import {
+ injectMeshchatThemeVariables,
+ vuetifyThemesFromTokens,
+ MESHCHAT_THEME_VARIABLES_LIGHT,
+ MESHCHAT_THEME_VARIABLES_DARK,
+ tailwindSemanticColorExtend,
+} from "../../meshchatx/src/frontend/theme/designTokens.js";
+
+describe("design tokens", () => {
+ it("light and dark variable maps define the same keys", () => {
+ expect(Object.keys(MESHCHAT_THEME_VARIABLES_LIGHT).sort()).toEqual(
+ Object.keys(MESHCHAT_THEME_VARIABLES_DARK).sort()
+ );
+ });
+
+ it("preserves legacy light palette anchors", () => {
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]).toBe("#f8fafc");
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-surface"]).toBe("#ffffff");
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-accent"]).toBe("#2563eb");
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-error"]).toBe("#dc2626");
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-info"]).toBe("#0284c7");
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-success"]).toBe("#16a34a");
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-warning"]).toBe("#f97316");
+ });
+
+ it("preserves legacy dark palette anchors", () => {
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]).toBe("#09090b");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-surface"]).toBe("#18181b");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]).toBe("#60a5fa");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-error"]).toBe("#f87171");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-info"]).toBe("#38bdf8");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-success"]).toBe("#34d399");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-warning"]).toBe("#fb923c");
+ });
+
+ it("vuetifyThemesFromTokens tracks CSS canvas/surface/accent and status colors", () => {
+ const t = vuetifyThemesFromTokens();
+ expect(t.light.colors.background).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]);
+ expect(t.light.colors.surface).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-surface"]);
+ expect(t.light.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-accent"]);
+ expect(t.light.colors.error).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-error"]);
+ expect(t.dark.colors.background).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]);
+ expect(t.dark.colors.surface).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-surface"]);
+ expect(t.dark.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]);
+ expect(t.dark.colors.error).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-error"]);
+ });
+
+ it("tailwindSemanticColorExtend uses var() for every semantic color", () => {
+ const sem = tailwindSemanticColorExtend();
+ for (const v of Object.values(sem)) {
+ expect(v).toMatch(/^var\(--mc-/);
+ }
+ });
+});
+
+describe("injectMeshchatThemeVariables", () => {
+ beforeEach(() => {
+ document.getElementById("meshchat-design-tokens")?.remove();
+ });
+
+ afterEach(() => {
+ document.getElementById("meshchat-design-tokens")?.remove();
+ });
+
+ it("injects :root and .dark blocks with expected canvas values", () => {
+ injectMeshchatThemeVariables(document);
+ const el = document.getElementById("meshchat-design-tokens");
+ expect(el).toBeTruthy();
+ const text = el.textContent;
+ expect(text).toContain(":root");
+ expect(text).toContain(".dark");
+ expect(text).toContain("--mc-canvas: #f8fafc");
+ expect(text).toContain("--mc-canvas: #09090b");
+ });
+
+ it("is idempotent", () => {
+ injectMeshchatThemeVariables(document);
+ injectMeshchatThemeVariables(document);
+ expect(document.querySelectorAll("#meshchat-design-tokens").length).toBe(1);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────